iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 22 篇

Day 15(下)|Availability:不是把 200 數一數

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:availability 不能只看 28 天的長期比率,也不能假設依賴失敗只有「全倒」一種樣貌;能不能在事故發生的當下就看到、看懂,取決於量測系統有沒有先把 good 的定義寫成可測的程式碼。

上篇把 availability 的分母拉回產品契約:先定義什麼是有效請求、什麼算好結果,再用低基數 label 讓 Prometheus 累計,PromQL 才從分子分母算出比率。下篇接著把上篇的 classifier 動手做出來,看依賴失敗與部分降級要怎麼量,再回頭問一個問題:28 天的長 window 跟事故當下的短 window,為什麼不能只看其中一個。

⑥ 28 天、1 小時與一個故障,看到的不是同一件事

SLO window 回答的是長期承諾;incident response 則要知道現在是否正在快速吃掉 error budget。只看 28 天比率,尖峰時段 20 分鐘的全面故障很容易被過去 27 天的正常流量沖淡。

長 window:是否接近違反長期 SLO?
短 window:現在的失敗率是否異常?
事件資料:哪個 status、版本、依賴或區域正在失敗?

這不等於要對每一個 5xx 發 Pager。Google SRE 對 SLO alert 的建議是追蹤顯著的 error-budget consumption,並在 precision、recall、detection time 與 reset time 之間取捨。Google SRE Workbook:Alerting on SLOs

burn rate 這個詞不需要想得太玄:如果 28 天的 SLO 是 99.5%,代表這 28 天總共只能「燒掉」0.5% 的 error budget,把這個總量平均攤到 28 天,就是正常情況下每天該燒掉的速度。burn rate 就是「目前實際消耗的速度」除以「這個正常速度」——burn rate = 2,代表照這個速度燒下去,28 天的 budget 會在 14 天內用完;burn rate = 14.4,代表過去 1 小時燒掉的預算,等於平常兩天份的量,這正是 Google SRE Workbook 建議拿來觸發 page 的量級之一。同時看短窗(如 5 分鐘)與長窗(如 1 小時)兩個 burn rate,用意是讓兩者都超標才真正告警:只看短窗容易被一次流量尖峰誤觸;只看長窗又會讓快速惡化的事件被拖慢發現。這正好回應前面「28 天、1 小時看到的不是同一件事」——burn rate 是把這句話變成一個可以寫進告警規則的數字。

多視窗、多燒錢速率:避開又慢又吵的兩難

只用短窗告警(例如 5 分鐘 bad rate 超過門檻就 page),會被一次流量尖峰或一次短暫的 deploy 誤觸;只用長窗告警(例如 6 小時平均),又會讓快速惡化的事件拖到使用者早就大量受害才被發現。Google SRE Workbook 給的解法不是「選一個折衷的窗口長度」,而是同時盯緊短窗與長窗兩個數字,兩者都超過門檻才真正告警:

短窗(例如 5 分鐘)超標 + 長窗(例如 1 小時)也超標
  → 現在正在快速惡化,且已經持續了一段時間,不是單次尖峰
  → page

只有短窗超標,長窗沒有
  → 可能只是一次瞬間尖峰,先觀察,不急著叫醒人
  → 記錄但不 page

只有長窗超標,短窗沒有
  → 惡化速度已經趨緩,可能正在自然恢復
  → 依 error budget 消耗程度決定是否需要人工介入

具體門檻要配合前面定義的 burn rate 倍數一起讀。若 SLO 是 99.5%(也就是 28 天內只能燒掉 0.5% 的 error budget),Google SRE Workbook 建議的其中一組告警組合大致是:

burn rate 短窗 長窗 代表的意義 建議動作
14.4 5 分鐘 1 小時 照這個速度,28 天的 budget 會在 2 天內燒光 page,立即處理
6 30 分鐘 6 小時 28 天的 budget 會在約 4.7 天內燒光 page,但可稍緩
1 6 小時 3 天 正常消耗速度的上限 記錄,日常檢視

寫成 PromQL,14.4 倍那一條大致長這樣:

(
  1 - (
    sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="good"}[5m]))
    /
    sum(increase(ask_sli_events_total{sli_eligible="true"}[5m]))
  )
) > (14.4 * 0.005)
and
(
  1 - (
    sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="good"}[1h]))
    /
    sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))
  )
) > (14.4 * 0.005)

0.005 是 SLO 允許的失敗比例(1 - 99.5%);14.4 * 0.005 就是這個燒錢速率下,短窗與長窗各自要超過的失敗率門檻。and 讓兩個條件都成立才觸發——這正是前面說的「兩個窗口都超標才 page」在查詢語言裡的樣子。

這一套機制不是憑空設計出來的學術練習。2025 年 10 月 20 日凌晨,AWS us-east-1 因為 DynamoDB 的 DNS 紀錄被意外清空,任何一個依賴 DynamoDB 的下游服務,如果裝了這種多視窗 burn-rate 告警,短窗與長窗會在幾分鐘內同時被點爆——完全不可用的依賴,燒錢速度遠遠超過 14.4 倍,這正是這套告警機制設計來抓的情境(詳見第⑦節的完整故事)。反過來說,如果告警規則沒有短窗這一層,只看長窗(例如 6 小時平均),這種在數小時內大致恢復的事故,很可能要等告警視窗跑完大半才會觸發——那時候,使用者早就已經受害了好幾個小時。

實例:從發生到完全恢復,只花了 25 分鐘

上面談的都是「花了很久才恢復」的故事,值得對照一個相反的例子:2025 年 12 月 5 日,Cloudflare 為了防護一個 React Server Components 漏洞(CVE-2025-55182),把 WAF 的緩衝區從 128KB 調大到 1MB;緊接著又停用了一個內部 WAF 測試工具,這個變更透過全域設定系統部署,沒有走漸進式推送機制,在數秒內就傳播到整個網路。程式碼裡有個 bug:當 killswitch 被套用到某條 execute 規則時,系統試圖存取一個已經被跳過、根本不存在的 execute 物件,丟出例外。時間軸壓縮到令人意外的地步:

08:47 UTC  設定變更部署
08:48 UTC  全域傳播完成(不到一分鐘)
08:50 UTC  宣告事故
09:11 UTC  開始恢復(還原設定)
09:12 UTC  完全恢復

從發生到完全恢復,總共大約 25 分鐘。Cloudflare:Cloudflare outage on December 5, 2025

把這次事故放進 burn-rate 的框架裡看,會發現一件矛盾但重要的事:「秒級全域傳播」原本聽起來是最危險的部署方式,沒有金絲雀、沒有漸進式 rollout,理論上應該讓爆炸半徑最大化。但因為受影響的流量(約占全部 HTTP 流量的 28%,只限舊版 FL1 proxy 加 Managed Ruleset 的特定組合)觸發的失敗率極高,短窗 burn rate 幾乎瞬間衝頂——不需要等 1 小時的長窗補上確認。同一套自動化用同樣速度還原設定,24 分鐘內完成偵測、宣告、修復。這說明 burn-rate 告警抓的不是「變更速度快不快」,而是「失敗訊號夠不夠明確、夠不夠快被看見」——這也是為什麼它跟第⑦節要談的 AWS DynamoDB 事故(十幾個小時才完全恢復)差距這麼大:後者的故障沿依賴鏈層層放大,每一層都需要各自偵測與修復;前者是同一個全域機制,壞得快也修得快。

這裡也印證了「availability 不是只有 up 或 down」:這次故障只命中「舊版 FL1 proxy」加「客戶部署了 Managed Ruleset」這個特定組合,其餘流量完全正常。若 SLI 只有全站層級的 up/down 開關,這種條件式的部分失效會被平均掉,看起來只是「錯誤率有點上升」;唯有像第④節那樣把低基數 label 分開看,才能在告警觸發當下就推測出故障可能只跟某個特定組合有關。

28 天是 rolling window,不是月曆月

另一個容易被忽略的細節:Google SRE Workbook 建議的 28 天窗口,是「過去 28 天」的滾動視窗,每天往前推移一天,而不是「本月 1 號到今天」的月曆月。差別看起來瑣碎,實際影響很大:月曆月的長度在 28 到 31 天之間跳動,同一個 SLO 百分比換算出來的允許失敗時間,每個月都不一樣,錯誤預算的比較也失去意義;更麻煩的是,月初的頭幾天永遠只有極少量歷史資料,availability ratio 會在月初劇烈震盪,跟系統實際表現無關,純粹是視窗太短造成的統計雜訊。rolling window 用固定的 28 天長度換掉月曆邊界,讓「這 28 天燒了多少 budget」在任何一天問,都有一致的比較基準,也是上面所有 increase(...[28d]) 查詢背後的假設。

一旦短窗與長窗同時超標,這條告警規則最終要接到值班系統。用 Alertmanager 的話,粗略的路由設定大致是:

route:
  receiver: default
  routes:
    - matchers:
        - alertname = "AskAvailabilityBurnRateCritical"
      receiver: pagerduty-primary-oncall
      continue: false
    - matchers:
        - alertname = "AskAvailabilityBurnRateWarning"
      receiver: slack-sre-channel
      group_wait: 5m

14.4 倍那一條規則對應 AskAvailabilityBurnRateCritical,直接 page 值班;6 倍那一條對應 AskAvailabilityBurnRateWarning,先進團隊頻道,讓人判斷要不要主動接手,而不是半夜被叫醒。這組對應關係要回頭跟第⑥節開頭那張門檻表一起讀——表格裡的「建議動作」欄位,就是這裡 receiver 該接到誰的依據。

對這個 Lab 而言,不必馬上把上面的 PromQL 與 Alertmanager 設定寫進 production;先把「短窗 + 長窗同時超標」這個判斷邏輯記在腦裡,理解它在解決哪一種兩難,比背下 14.4 這個數字更重要——數字要依你實際的 SLO 目標重新推算。可以先把下面幾個問題放進 dashboard,而不是急著寫 production pager rule:

過去 1 小時:bad / valid 是多少?
過去 6 小時:bad event 的主要 reason 是什麼?
過去 28 天:剩餘 error budget 是多少?
現在是否有足夠 valid requests 讓比例可解讀?

告警門檻、burn-rate multiplier、通知對象與 escalation policy 都需要真實流量、產品影響與值班能力才能定。本文不替尚未量測的 Lab 宣稱任何 pager 設定已驗證。

⑦ 依賴失敗時,availability 不是只有 up 或 down

AI workflow 常同時依賴 retrieval index、embedding service、LLM provider、tool API 與 policy engine。這些其中一個壞掉,不一定要讓整個 /ask 回 500;但「可以降級」必須是明確、可測的服務契約。

故障 可能行為 availability 是否 good
retrieval 無回應 回覆暫時無法查詢知識庫 取決於產品是否承諾此狀態可用
LLM provider timeout 切到已核准的 fallback model 若仍在 deadline 內且 contract 成立,可為 good
citation validator 故障 拒答並提示稍後再試 通常是 valid-but-bad,除非契約明定人工流程接手
tool API 403 不執行工具,回傳權限說明 若權限拒絕正確且是預期行為,可能為 good
tool API 500 停止 workflow,回明確錯誤狀態 通常是 bad

實例:一個 DNS 紀錄清空,讓 health check 自己也一起說謊

2025 年 10 月 19 日深夜 11:48(PDT),AWS us-east-1 的 DynamoDB 區域端點開始出現大量 API 錯誤。根因是 DynamoDB 內部負責管理 DNS 的自動化系統裡,一個潛伏的競態條件:兩個獨立運作的 DNS Enactor 元件同時想更新同一個區域端點的 DNS 紀錄,一個套用了舊的變更計畫,另一個幾乎同時把它刪除,結果是這個區域端點的 DNS 紀錄被清空——dynamodb.us-east-1.amazonaws.com 一度完全解析不到任何 IP。AWS 官方事後報告的時間軸大致是:11:48 PM 事件開始,12:38 AM 找到根因,1:15 AM 部分內部服務靠臨時處置恢復連線,2:25 AM DNS 資訊完全恢復,2:40 AM DynamoDB 本身完成復原,但要到隔天下午 2:20 PM 左右,所有下游服務才完全恢復正常。AWS:Summary of the Amazon DynamoDB Service Disruption in the Northern Virginia (US-EAST-1) Region

這次事故最值得放進本節討論的地方,是故障如何沿著依賴鏈往外擴散,而且擴散路徑裡包含了 health check 本身:EC2 內部負責追蹤租用狀態的 DWFM(DropletWorkflow Manager)依賴 DynamoDB 做狀態檢查,DynamoDB 打不通,DWFM 的狀態檢查開始大量失敗;新啟動的 EC2 instance 網路設定推送延遲,連不上網路;Network Load Balancer 的健康檢查看到這些新機器連不上,判定「不健康」並從 endpoint 列表移除——原本該保護使用者的健康檢查,反而成了放大故障的一環。Lambda、SQS 陸續受影響,甚至連 IAM 憑證驗證都牽連在內:Redshift 的使用者群組解析要繞經 us-east-1 的 IAM API,於是一個區域性 DNS 問題,變成了全球 Redshift 客戶都連不上的問題。

對照本節開頭那張表格,這裡沒有一個依賴「單純地 down 掉」——DynamoDB 自己短短幾分鐘內就從技術上恢復,但依賴它的 EC2 租用管理、NLB 健康檢查、Lambda 事件處理各自用不同機制把故障放大並延後,使得完整恢復要再等上超過十二小時。這正是本節反覆強調的:「依賴失敗」不是一個 boolean,它是一連串「這一層原本該保護使用者,結果反而成為故障放大器」的鏈條——而其中一環,恰好就是本文第②節談過的 health check 本身。

實例:沒有服務真的 down,卻有 12 Gbps 流量被默默丟棄

上面兩個案例都有一個明確的「壞掉」時刻——DynamoDB 的 DNS 紀錄被清空、DWFM 狀態檢查大量失敗。但依賴失敗更常見的樣貌,其實更安靜。2026 年 1 月 22 日 20:25 UTC,Cloudflare 一個自動化的路由政策設定變更,移除了 Bogota 基礎設施原本該有的 prefix filter,產生一條過度寬鬆的政策:比對條件寫成「route-type internal」,結果符合的是「任何非外部路由」。後果是 Cloudflare 內部骨幹網路間彼此重新分送的所有 IPv6 前綴,全部被這條政策接受,並廣播給 Miami 的所有 BGP 鄰居——連原本屬於 Meta(AS32934)的路由,都被重新廣播給上游供應商 Lumen(AS3356)。一部分流量因此被意外導流經過 Miami 資料中心,尖峰時約有 12 Gbps 的非客戶流量被防火牆規則丟棄,Miami-Atlanta 骨幹鏈路壅塞,部分客戶流量出現延遲上升與封包遺失。Cloudflare:Route leak incident on January 22, 2026

這起事故從頭到尾,沒有任何一個 Cloudflare 服務被判定為「down」——沒有 5xx 暴增、沒有服務被下線。它的樣貌是「部分流量延遲上升、部分封包遺失」,若 availability SLI 只有「服務是否可達」這種粗粒度二元訊號,這種劣化幾乎完全看不到;必須另外量測延遲分佈與封包遺失率才捕捉得到。更值得注意的是偵測方式:事故 20:25 UTC 發生,直到 20:40 才開始調查,中間 15 分鐘完全沒有自動化告警,是網路團隊自己發現流量不對勁才啟動處理;20:44 宣告事故,20:50 人工還原,前後 25 分鐘——事後報告點名這次偵測仰賴人工調查,而非自動化異常偵測。

跟 12 月 5 日事故並排看:兩者「修復速度」差不多快,但 12 月 5 日有自動化告警瞬間觸發,這次卻有 15 分鐘偵測空窗、完全靠人發現。就算回滾機制一樣快,若沒有對應訊號讓短窗 burn rate 及時抓到異常,「有沒有人發現」這一步本身就可能吃掉本來不必燒掉的 error budget——不是所有依賴失敗都會讓分子分母立刻反映異常,有些故障需要專門設計來偵測劣化而非中斷的訊號。

這裡最常見的作弊方式,是把「fallback 已觸發」一律算成成功。使用者若仍取得符合契約的結果,fallback 當然可以是 good;但 fallback 若默默拿掉 citation、縮短答案或改變安全限制,SLO 要依新契約重新判定。不要為了維持綠色曲線,讓服務在背後偷換功能。

把降級記進 trace,而不是塞進高基數 metric

每次 fallback 或 partial result,都應在 structured log 或 trace 加上可搜尋的欄位:

{
  "request_id": "req_7d4e",
  "trace_id": "trace_91ab",
  "workflow_name": "policy_qa",
  "response_status": "completed",
  "sli_result": "good",
  "fallback_triggered": true,
  "primary_model": "provider-a/model-x",
  "served_model": "provider-b/model-y",
  "prompt_version": "policy-qa-v3"
}

request_id 和 trace_id 可讓值班者從 SLO 圖一路追到那一筆 workflow。它們不能出現在 Prometheus label;同一個欄位在不同觀測層有不同用途,這正是可觀測性設計該分層的原因。

⑧ 今日 DIY:建立可測的 availability classifier

以下內容是讀者自行實作的教材。本文沒有建立 Day15/DIY、沒有安裝套件、沒有啟動服務,也沒有執行測試;請在自己的 Day15 DIY 專案中完成並對照結果。

這個 DIY 要驗證的,是第①到④節反覆強調的一件事:「good 的定義必須被寫成程式碼、被測試保護,而不是留在文章或口頭共識裡」。五個步驟依序是:定義契約 → 用測試固定契約 → 讓 metric 由契約產生(而不是由 HTTP status 猜)→ 用兩組流量驗證分母沒有算錯 → 用一張不說謊的 dashboard 呈現結果。記住這個順序——先有契約才有 metric,而不是反過來從一堆 metric 裡猜契約——比背下任何一段 PromQL 語法更有用。

步驟 1:寫下可版本控制的契約

建立 app/availability.py。先把可變動的產品決策集中在常數與 classify_availability(),不要散落在 FastAPI route、PromQL 與 dashboard JSON 裡。

from dataclasses import dataclass
from enum import StrEnum


class SliResult(StrEnum):
    GOOD = "good"
    BAD = "bad"
    EXCLUDED = "excluded"


@dataclass(frozen=True)
class AvailabilityEvent:
    valid_request: bool
    latency_ms: int
    contract_valid: bool
    response_status: str


@dataclass(frozen=True)
class Classification:
    eligible: bool
    result: SliResult
    reason: str


GOOD_RESPONSE_STATUSES = frozenset(
    {"completed", "insufficient_context", "requires_human_review"}
)
MAX_LATENCY_MS = 10_000


def classify_availability(event: AvailabilityEvent) -> Classification:
    if not event.valid_request:
        return Classification(False, SliResult.EXCLUDED, "invalid_request")
    if event.latency_ms > MAX_LATENCY_MS:
        return Classification(True, SliResult.BAD, "deadline_exceeded")
    if not event.contract_valid:
        return Classification(True, SliResult.BAD, "invalid_response_contract")
    if event.response_status in GOOD_RESPONSE_STATUSES:
        return Classification(True, SliResult.GOOD, "contract_satisfied")
    return Classification(True, SliResult.BAD, "workflow_failed")

這是一個 Lab contract,不是所有 AI 服務的 policy。MAX_LATENCY_MS、good statuses 與 invalid request 的定義,必須與你在 Day 14 寫下的 SLO spec 對齊。常見的坑是把這幾個常數直接寫死在 FastAPI route handler 裡,而不是集中在這個模組——一旦改動就容易漏掉某一處。

為什麼用 frozen dataclass 加 StrEnum

這不是風格偏好,是用型別系統擋掉一整類錯誤:

  • frozen=True 讓 AvailabilityEvent/Classification 建立後不能被改動——若有程式碼在分類完之後又回頭「修正」latency_ms,通常代表更嚴重的邏輯問題(例如把重試延遲疊加算成一次請求),frozen 讓這種修改在執行期直接丟 FrozenInstanceError。
  • 用 dataclass 而非 dict,讓欄位打錯字(如 contract_valie)在建構時就被抓到,而不是等某條測試剛好覆蓋到那個分支才被發現。
  • SliResult 用 StrEnum 而非裸字串,避免「意思相同、拼法不同」的字串到處長出分身——若某處拼成 "Good",字串比對會靜靜地永遠是 False;StrEnum 把唯一合法拼法集中在一處,拼錯字會在 import 或型別檢查階段就現形。

這三點合起來,是把「什麼算 good」從一份可以被隨手改動的共識,變成被型別系統與測試共同鎖住的契約,呼應第④節「用事件分類取代猜 status code」。

步驟 2:先寫 fixture,再寫 dashboard

建立 tests/test_availability.py。fixture 的目標是把產品討論變成可重跑的例子,而不是測 Python 語法。

import pytest

from app.availability import (
    AvailabilityEvent,
    SliResult,
    classify_availability,
)


@pytest.mark.parametrize(
    ("event", "eligible", "result", "reason"),
    [
        (
            AvailabilityEvent(True, 230, True, "completed"),
            True,
            SliResult.GOOD,
            "contract_satisfied",
        ),
        (
            AvailabilityEvent(True, 340, True, "insufficient_context"),
            True,
            SliResult.GOOD,
            "contract_satisfied",
        ),
        (
            AvailabilityEvent(True, 10_001, True, "completed"),
            True,
            SliResult.BAD,
            "deadline_exceeded",
        ),
        (
            AvailabilityEvent(True, 400, False, "completed"),
            True,
            SliResult.BAD,
            "invalid_response_contract",
        ),
        (
            AvailabilityEvent(False, 0, False, "invalid_request"),
            False,
            SliResult.EXCLUDED,
            "invalid_request",
        ),
    ],
)
def test_classify_availability(event, eligible, result, reason):
    actual = classify_availability(event)

    assert actual.eligible is eligible
    assert actual.result is result
    assert actual.reason == reason

執行指令依你的 DIY 專案設定為準。若使用 Day 目錄的 uv 專案結構,可從 Day15/DIY 執行:

uv run pytest

預期結果不是某個漂亮的 coverage 數字,而是每一筆 fixture 都清楚表明它是 good、bad 或 excluded。若你改掉一個判定,先問「服務契約是否真的改了?」再更新測試;不要只為了測試轉綠而改預期值。

這個步驟驗證的是第④節的核心論點:分類邏輯要能被測試保護。實際跑起來時,比較容易踩的坑不是測試失敗,而是「測試全部通過,但少了關鍵情境」——例如沒有為 contract_valid=False 但 response_status="insufficient_context" 這種組合寫 fixture(模型誠實拒答,但回傳的 JSON 缺了必要欄位),第一次遇到時只能臨場決定要不要算 good,而不是照著已討論過的規則走。建議完成前四個 parametrize 案例後,回頭想想服務契約裡還有哪些真實會發生的組合沒被涵蓋。

缺漏的第六個 fixture

把「誠實拒答但缺欄位」寫成 fixture:

(
    AvailabilityEvent(True, 280, False, "insufficient_context"),
    True,
    SliResult.BAD,
    "invalid_response_contract",
),

這裡 response_status 是白名單裡的 "insufficient_context",但 contract_valid=False。因為 contract_valid 的檢查排在 response_status 之前,這筆事件會落在 invalid_response_contract 分支判定為 BAD——即使模型「說了實話」。它測的不是新分支,而是「兩個訊號互相矛盾時,判斷順序有沒有照原本想的方式運作」;哪天有人把這兩個 if 對調,這筆 fixture 會是第一個失敗的測試。

步驟 3:由 classifier 輸出 metric

在自己的 API route 完成 response validation 後,將結果轉成 counter。這裡用 prometheus_client 示範介面;實際 import 與 metrics endpoint 請依你的 Day2 Lab 配置調整。

from prometheus_client import Counter

from app.availability import classify_availability


ask_sli_events_total = Counter(
    "ask_sli_events_total",
    "Availability SLI events for /ask",
    ("sli_eligible", "sli_result", "response_status"),
)


def record_sli_event(event) -> None:
    classification = classify_availability(event)

    ask_sli_events_total.labels(
        sli_eligible=str(classification.eligible).lower(),
        sli_result=classification.result.value,
        response_status=event.response_status,
    ).inc()

刻意不要把 reason 加成 metric label。若 reason 的值被錯誤訊息或第三方內容污染,基數會失控。只要 response_status 是固定列舉值,保留它通常足夠做 first-level breakdown;更細的理由到 logs 與 traces 查。

跑這一步時常見的落差是:metric 已經正確遞增,但 Grafana 或 Prometheus 上完全看不到新資料。檢查順序建議由外而內:/metrics 有沒有被 Prometheus 的 target 成功 scrape(up{job="..."} 是不是 1)、counter 的 label 拼字是否每次一致(prometheus_client 對同一組 label 只會建立一條 time series,拼字打錯會悄悄建出另一條沒人查詢的線)、scrape interval 是否還沒輪到下一次(Day 2 Lab 若沿用預設 15 秒,剛送出的請求要等下一次才看得到)。

/metrics 被 scrape 之後長什麼樣

prometheus_client 用 make_asgi_app() 掛上 /metrics 後,直接 curl 應該看到類似輸出:

# HELP ask_sli_events_total Availability SLI events for /ask
# TYPE ask_sli_events_total counter
ask_sli_events_total{response_status="completed",sli_eligible="true",sli_result="good"} 8.0
ask_sli_events_total{response_status="completed",sli_eligible="true",sli_result="bad"} 2.0
ask_sli_events_total{response_status="invalid_request",sli_eligible="false",sli_result="excluded"} 5.0

若這裡的 # TYPE 顯示 gauge 而非 counter,代表 metric 定義被改錯型別,後面所有 increase()/rate() 都會失準。每一行是獨立的時間序列,sli_result="Good"(大寫)跟 sli_result="good" 對 Prometheus 是兩條不同的線——label 拼字必須固定,否則查詢結果會被悄悄拆成兩份而沒人發現。

若 curl 完全看不到 ask_sli_events_total,問題在應用程式本身(檢查 record_sli_event() 有沒有被呼叫到);若 curl 看得到數字但 up{job="..."} 是 0,問題在網路或 target 設定,不是程式碼。

步驟 4:用兩組 traffic 驗證分母

請自行送出或模擬兩組 event:

情境 A
- 8 筆 valid + good
- 2 筆 valid + bad

情境 B
- 5 筆 invalid request

預期:情境 A 的 availability 是 8 / 10 = 80%。情境 B 不應把結果改成 8 / 15,因為那五筆不在 valid-request 分母。

若你的 Prometheus 可以查詢,可用下面兩個 query 對照 classifier 的輸出:

sum(increase(ask_sli_events_total{sli_eligible="true",sli_result="good"}[1h]))
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))

請記錄你使用的時間窗、測試流量來源與 scrape interval。短時間內 counter 沒出現,不一定是 classifier 壞掉,也可能是 scrape 尚未發生;不要跳過資料流檢查就直接改 query。

這一步驗證的是第①、⑤節反覆強調的分母定義:「有效請求」與「全部請求」是兩個不同的集合。若算出來的比率變成 8 / 15,代表某處把 sli_eligible=false 的事件也算進了分母——回頭檢查步驟 3 的 label 賦值,而不是懷疑 PromQL 語法。

用一支腳本把驗證串成可重跑的流程

比起手動送幾筆事件再肉眼核對,更可靠的做法是寫一支腳本,把「建構情境 A 與 B → 呼叫 classifier → 統計三種結果 → 斷言分母不含 excluded」串起來,每次改動 classifier 都能重跑:

from app.availability import AvailabilityEvent, SliResult, classify_availability

SCENARIO_A = (
    [AvailabilityEvent(True, 300, True, "completed")] * 8
    + [AvailabilityEvent(True, 10_500, True, "completed")] * 2
)
SCENARIO_B = [AvailabilityEvent(False, 0, False, "n/a")] * 5


def summarize(events):
    counts = {SliResult.GOOD: 0, SliResult.BAD: 0, SliResult.EXCLUDED: 0}
    for event in events:
        counts[classify_availability(event).result] += 1
    return counts


def main():
    a = summarize(SCENARIO_A)
    combined = summarize(SCENARIO_A + SCENARIO_B)

    denominator_a = a[SliResult.GOOD] + a[SliResult.BAD]
    denominator_combined = (
        combined[SliResult.GOOD] + combined[SliResult.BAD]
    )

    print(f"Scenario A denominator: {denominator_a} (expect 10)")
    print(f"Combined denominator: {denominator_combined} (expect 10, NOT 15)")
    assert denominator_a == 10
    assert denominator_combined == 10, "invalid requests leaked into denominator"
    print(f"Availability: {a[SliResult.GOOD]}/{denominator_a} = "
          f"{a[SliResult.GOOD] / denominator_a:.1%}")


if __name__ == "__main__":
    main()

這支腳本的重點不是印出漂亮的百分比,是最後那一行 assert——把「分母不該被污染」寫成斷言,而不只是印出來讓人肉眼核對,這個驗證就能接進 CI:只要分母計算有誤,腳本會直接以非零 exit code 失敗。

步驟 5:做一張不會騙人的小 dashboard

第一版 dashboard 不必塞滿圖,四個 panel 就能先看出定義是否一致:

Panel 問題 觀察重點
valid events 這個比例有多少樣本? 低流量時不要把 100% 當結論
availability ratio good / valid 是多少? 分子分母是否與 spec 相同
bad by status 壞在何處? timeout、contract、workflow 是否突然偏高
excluded events 被排除的是什麼? 突然上升可能是 client 或 validation 變化

若 Grafana 顯示 No data,先確認資料有沒有進來(metric endpoint 是否有 counter、target 是否 UP、time range 是否包含測試事件),再討論 SLO。

這張 dashboard 對應第⑨節「常見的四種錯算」:valid events 樣本數只有個位數、ratio 面板卻顯示精確到小數點後兩位,就是「低流量假裝有精準數字」;某次改動後 excluded 曲線無故墊高、bad by status 同時下降,通常代表分類規則被悄悄放寬,而不是系統真的變健康。

四個 panel 對應的 PromQL:

# valid events(分母的分子——排除掉 excluded 的事件數)
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))

# availability ratio
sum(increase(ask_sli_events_total{sli_eligible="true",sli_result="good"}[1h]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))

# bad by status
sum by (response_status) (
  increase(ask_sli_events_total{sli_eligible="true",sli_result="bad"}[1h])
)

# excluded events
sum(increase(ask_sli_events_total{sli_eligible="false"}[1h]))

四條 query 共用同一個 metric,差別只在 label filter 與是否用 sum by。這是故意的設計:全部從同一個 ask_sli_events_total 切出來,任何一個 panel 對不上,都可以直接懷疑是查詢寫錯,而不必先懷疑接錯了 data source。

延伸:接上 Day 2 的 observability stack

以上五步只需要 Python 直譯器,不需要 Docker 或 Prometheus server。若想把 /metrics 接上 Day 2 建好的 Prometheus + Grafana,會碰到三個實務環節:prometheus.yml 要多加一個 scrape target 指到 DIY 服務的 host:port(注意容器內的 localhost 指的是容器自己,不是宿主機);Grafana 沿用 Day 2 既有的 Prometheus datasource 即可,不需要每個 Day 各建一份;這個 DIY 的 counter 跟 Day 2 示範的 http_requests_total 是兩個獨立 metric,彼此不會自動關聯,要疊在同一張 dashboard 看,得先手動確認兩者的時間範圍與 scrape interval 一致。

五個步驟,各自驗證了前文哪一句話

跑完五個步驟之後,回頭把每一步對應到前面章節的具體論點,比記住程式碼本身更值得留下來:

步驟 對應章節論點 若這步驟被跳過,會發生什麼
1. 寫下契約 第①節「valid request 是產品契約問題,不是 schema 驗證問題」 判斷規則散落在 route handler 各處,沒有單一真相來源
2. fixture 保護判定 第④節「用事件分類取代猜 status code」 改一次規則沒人知道,測試轉綠只是巧合,不是保證
3. classifier 輸出 metric 第④節「在請求完成後才記錄」 metric 可能在請求還沒真正結束前就被記,變成預測而非量測
4. 兩組流量驗證分母 第①、⑤節「有效請求」與「全部請求」是兩個集合 excluded 事件悄悄污染分母,比率虛假偏高卻沒人發現
5. 不說謊的 dashboard 第⑨節「常見的四種錯算」 低流量時裝作有精準百分比,或分類被悄悄放寬卻沒人注意

這張表格是一個提醒:五個步驟是同一條論證鏈——先有契約、契約被測試鎖住、metric 由契約而非猜測產生、用已知流量驗證分母沒有算錯、最後才用不說謊的方式呈現——被拆成五段可以個別重跑的練習。若只做步驟 1 跟 3、跳過 2 跟 4,會得到一個「看起來能動」的 metric pipeline,但沒有東西能告訴你它算出來的數字,跟你原本想量的是不是同一件事。

DIY 驗收清單

  • [ ] SLO spec 寫清楚 valid request、good event、bad event 與 excluded event。
  • [ ] classifier 對正常完成、誠實拒答、逾時、contract failure、無效 request 都有 fixture。
  • [ ] good/bad/excluded 的判定由程式碼測試保護,不只寫在文章裡。
  • [ ] metric label 沒有放入 request_id、prompt、user ID 或 exception message。
  • [ ] dashboard 同時顯示 valid event count 與 availability ratio。
  • [ ] 測試流量中的 invalid request 沒有被錯算進分母。
  • [ ] 能從一筆 bad event 的 status 回到對應 log 或 trace。
  • [ ] 已記錄本文範例的 10_000 ms 是 Lab 假設,尚非 production SLO。

本文沒有替你執行以上任何步驟。完成後,請將你的結果、環境版本與觀察到的限制寫入 Day15 的 DIY README;它是下一次改 classifier 時最有用的對照資料。

⑨ 常見的四種錯算

這四種錯算有一個共同的結構:都是「某個中間環節把失敗吸收掉了,而量測邏輯只看得到吸收之後的結果」。retry 吸收了第一次失敗、cache 吸收了依賴故障、後端寫出 token 這個動作吸收了使用者實際看到內容這件事、excluded 分類本身吸收了 bad event 的存在。吸收失敗不是壞事——retry 跟 cache 都是刻意設計的韌性機制,目的正是讓使用者少感受到一點故障。問題出在量測邏輯如果只站在吸收層的後面看,會把「韌性機制正在拚命工作」誤讀成「系統很健康」,兩者在數字上長得一模一樣,但代表的風險完全不同:前者代表系統的餘裕正在被消耗,後者代表真的沒事。

錯算一:把 retry 後成功當成永遠沒失敗

若 client 先收到 timeout,重試後才成功,server-side counter 可能只看見第二次成功。對使用者而言,第一次等待仍可能違反 latency contract。

Client 送出請求 (t=0s)
  ↓
Server 處理逾時,回傳 error (t=8s)
  ↓
Client 自動重試 (t=8s)
  ↓
Server 成功回應 (t=9.2s)

server_attempt_availability:兩次 attempt,一次 bad、一次 good → 50%
user_journey_availability:使用者等了 9.2 秒才拿到答案,且中途看到一次錯誤 → 是否符合 latency contract,要另外定義

要決定量 server attempt、client journey,或兩者都量;名稱要寫清楚,例如 server_attempt_availability 與 user_journey_availability,不能混在同一條線上。只回報前者,會讓一個對使用者而言明顯變慢、甚至短暫閃過錯誤畫面的體驗,在 dashboard 上看起來跟完全順暢的請求一樣好。

常見誤解:以為「多記一個 attempt counter」就等於量到了 user journey

有些團隊發現 retry 會扭曲數字之後,第一個直覺是把 sli_eligible/sli_result 的 counter 拆成兩層:一層記每一次 attempt,一層記「這一輪 retry 全部結束後」的最終結果。這個方向是對的,但常見的實作漏洞,是用「同一個 request 物件」去累計兩層 counter,而不是用一個貫穿整輪重試的識別碼(idempotency key 或 client-generated request id)去串連:

async def ask_with_journey_tracking(payload: AskRequest, deps) -> AskResponse:
    journey_id = payload.idempotency_key  # 由 client 產生,跨重試保持不變
    attempt = 0
    last_result = None

    while attempt < MAX_ATTEMPTS:
        attempt += 1
        started_at = monotonic()
        try:
            result = await run_ask_workflow(payload, deps)
            record_attempt_event(journey_id, attempt, "good")
            record_journey_event(journey_id, attempts=attempt, result="good")
            return result
        except WorkflowTimeoutError:
            record_attempt_event(journey_id, attempt, "bad")
            last_result = "timeout"
            continue

    record_journey_event(journey_id, attempts=attempt, result="bad", reason=last_result)
    raise HTTPException(status_code=502, detail=last_result)

record_attempt_event() 對應 server_attempt_availability,每次呼叫都記一筆;record_journey_event() 只在整輪重試真正結束時記一筆,帶著 attempts 欄位——這欄位不做 label(重試次數分佈可能很廣,容易讓 cardinality 失控),而是進 log 或 trace 供事後排查。少了 journey_id 貫穿整輪重試,兩層 counter 各自遞增互不相干,只會得到兩條看起來合理、卻對不起來的曲線。

錯算二:把 cache hit 當成所有依賴都健康

cache hit 可以讓使用者順利完成請求,因此在使用者 availability 中可能是 good。它卻可能遮住 retrieval 或 model provider 已經失敗。保留 dependency health、fallback rate 與 cache-hit rate,才能知道「服務還能回答」和「後端已經失火」是否同時存在。這正是第⑦節談的依賴降級:cache 本身常常就是那個「fallback 已觸發」卻沒有被記錄下來的環節——如果連 cache 都失效,使用者看到的會是毫無預警的全面故障,而不是逐步惡化的訊號。

具體要保留哪三條線,用 PromQL 表示大致是:

# 使用者感受到的 availability:cache hit 也算 good,這條線在依賴故障時可能仍然很高
sum(increase(ask_sli_events_total{sli_eligible="true", sli_result="good"}[1h]))
/
sum(increase(ask_sli_events_total{sli_eligible="true"}[1h]))

# 後端真實健康度:retrieval 與 model provider 是否真的能回應(不管有沒有被 cache 擋掉)
sum(increase(dependency_health_check_total{dependency="retrieval", result="ok"}[1h]))
/
sum(increase(dependency_health_check_total{dependency="retrieval"}[1h]))

# cache 承擔了多少流量:這條線越高,代表「使用者 availability 看起來健康」有多少是靠 cache 撐住的
sum(increase(cache_lookup_total{result="hit"}[1h]))
/
sum(increase(cache_lookup_total[1h]))

三條線一起看,才回答得出「使用者 availability 99.9%、cache-hit rate 也高達 95%、但 retrieval 健康度只剩 40%」這種情境——它代表系統目前完全靠 cache 撐著門面,一旦 cache TTL 到期或被清空,使用者馬上會感受到斷崖式的下降。只看第一條線的人,會在 cache 失效的那一刻才第一次意識到後端早就在失火,而不是提前看到警訊。

錯算三:只量後端,不量串流中斷

streaming response 在後端開始送出 token 時可能被記成成功,但使用者在中途斷線,或 browser 根本沒有完成渲染。若產品承諾串流回答,應另外設計 completion signal——例如在 SSE(Server-Sent Events)或 WebSocket 的資料流末端送出一個明確的 event: done 事件,並要求前端在收到這個事件、且成功渲染最後一段內容後,才回報一次完整的 user-journey-good 事件;server 只是把資料寫進 socket,不代表使用者真的看到了完整答案。server 成功寫出第一個 byte 不等於 user journey 完成。

用 SSE 的格式具體表示,後端在串流結束時大概要多送這樣一行:

data: {"token": "。"}

event: done
data: {"status": "completed", "total_tokens": 214}

關鍵在 event: done 這一行必須帶著跟第④節同一套 response_status 語彙——這裡的 "completed" 要能對應到 classify_availability() 認得的值,而不是前端另外發明一套跟後端不同步的狀態字串。前端收到這個事件後,才透過一個獨立的 endpoint(例如 POST /client-events/journey-complete)回報「使用者的瀏覽器端確實收到了結束訊號」;如果前端在最後一個 token 之後五秒都沒收到 event: done,代表連線可能中途斷了,這時前端應該自己標記一次 journey_incomplete,而不是預設沉默、讓 server 端的 metric 誤以為一切正常。這類 client-side SLI 很有價值,但收集與隱私設計也更複雜,先把邊界寫清楚:要不要收集匿名的完成率、要不要區分「使用者主動關掉分頁」與「連線被意外中斷」,這些都是先於任何程式碼的產品決策。

錯算四:用 excluded 美化曲線

排除項目必須少、穩定、可稽核。某版本上線後 bad event 增加,卻同時把 response_validation_failed 改標 excluded,SLO 會變綠,使用者不會。用第⑤節的小數字驗算一次就能看出這個把戲多有效:原本 9,940 good / 10,000 valid = 99.4%,把其中 300 筆 bad 事件悄悄改標成 excluded 之後,分母變成 9,700,比率瞬間「進步」到 9,940 / 9,700——這個數字本身已經超過 100%,明顯是算錯,但真實世界裡更常見的手法是只改標一小部分、讓比率剛好回到 SLO 門檻之上,不會大到讓人起疑。每次修改分類規則都應視為 reliability change,記錄變更時間、owner 與理由,並在 dashboard annotation 留下痕跡——這樣下一次比率無故變好時,值班的人第一個念頭會是去查 annotation,而不是恭喜自己。

⑩ Incident walkthrough:一條綠線怎麼救不了你

假設週一 10:00 部署 prompt-v4 後,模型仍大量回 200,但 parser 對新的欄位格式不相容。若你的 SLI 寫成 http_status < 500,availability 仍接近 100%。客服開始收到「畫面空白」回報,dashboard 卻一片綠。

改用本文的 contract-aware classifier,這些回應會變成:

valid_request=true
contract_valid=false
response_status=completed
sli_result=bad
reason=invalid_response_contract

值班的人可以依這個順序處理:

  1. 看 bad by status 是否與部署時間重合。
  2. 從 trace 以 prompt_version=prompt-v4 篩選失敗樣本。
  3. 確認 parser 的 contract 是否在 prompt 變更後失效。
  4. 暫停 rollout、回退 prompt,或套用兼容 parser。
  5. 重跑 fixture,加入這個 response shape,避免下一次再次被 200 騙過。

把這五步對應成時間軸,兩種監控方式的差距會更清楚:

時間 只看 HTTP status 的 dashboard 用 classifier 的 dashboard
10:00 部署完成,一切正常 部署完成,一切正常
10:03 仍是綠燈 bad by status{reason="invalid_response_contract"} 開始上升
10:05 仍是綠燈 短窗 burn-rate 觸發 warning
10:12 仍是綠燈 長窗 burn-rate 同時超標,critical alert 觸發,值班被 page
10:20 第一筆客服工單進來 已在查 trace,鎖定 prompt_version=prompt-v4
10:45 值班才剛開始查「使用者說空白是什麼意思」 rollback 已完成,fixture 已補上這個 response shape

差距不是監控工具本身的能力,是分類邏輯有沒有站在正確的地方看事情。HTTP status 這一欄從頭到尾都是對的——伺服器確實回了 200,這不是謊言,只是回答了一個使用者從來沒問過的問題。

這不是完整的 incident runbook,但它把「使用者說空白」連到可量測的事件分類。根因可能在 prompt、parser、SDK 或 deployment;availability SLI 的責任不是猜根因,而是用可靠的失敗訊號讓調查有地方開始。

如果這次事故裝了第⑥節那種多視窗 burn-rate 告警,短窗與長窗會在部署後幾分鐘內同時超標——因為 contract_valid=false 的比例會立刻跳升,不需要等使用者投訴才被發現。這也是本文一路主張「用 classifier 產生 metric,而不是用 HTTP status 猜」的實際回報:同一次事故,兩種量測方式看到的告警時間點,可能差了好幾個小時,而這幾個小時裡,使用者看到的都是同一片空白畫面。

這個場景跟第①節的 BYOIP 事故,其實是同一種測試漏洞

把這次假想的 prompt-v4 事故跟第①節談過的 Cloudflare BYOIP 事故並排看,會發現兩者的根因結構驚人地相似:兩邊都是「每一次操作本身都回報成功」,兩邊事後追查都指向「測試覆蓋率沒有涵蓋這個特定情境」。差別只在於規模與領域——一邊是內部自動化任務對參數語意的誤判,一邊是 prompt 變更後 parser 對欄位格式的誤判——但「contract 被違反,卻沒有任何一層丟出錯誤」這個故障形狀完全一樣。這也是為什麼第⑧節的 DIY 驗收清單裡,特別把「classifier 對正常完成、誠實拒答、逾時、contract failure、無效 request 都有 fixture」列成必須項目,而不是「有測試就好」:測試的價值不在於數量,在於有沒有涵蓋這種「技術上完成、契約卻悄悄壞掉」的組合。

如果這次部署接上一個以 SLI 為門檻的自動化 rollout gate,第 5 步「暫停 rollout」甚至不需要等人手動按下按鈕:

# 簡化示意:canary 階段的自動晉升規則
rollout_gate:
  metric: ask:availability_ratio:5m
  promote_if: value >= 0.995
  halt_if: value < 0.99
  evaluation_window: 5m
  min_sample_size: 200

min_sample_size 這個欄位呼應第⑤節「樣本數不夠時,百分比本身就是雜訊」——canary 階段流量通常遠少於全量,若沒有這個門檻,一個只服務了 20 個請求的 canary,很容易因為統計雜訊被誤判為「不健康」而擋下一次其實沒問題的部署,或者相反地被誤判為「健康」而放行一次其實已經壞掉的部署。這正是第⑤節「樣本不足時寧可顯示樣本不足,也不要硬算比率」的原則,套用在部署決策而不是值班儀表板上的樣子——同一個統計陷阱,會在觀測層與部署自動化層都出現,因為兩者最終都在問同一個問題:這個比率,現在能不能被信任。

⑪ 本文結論

availability 的分母是產品判斷,不是監控工具的預設值。先定義 valid request,再定義好結果;接著把判定寫成測試保護的 classifier,最後才用 counter、PromQL 與 dashboard 量它。

把這條主線倒過來看一次會更清楚為什麼順序不能顛倒:如果先寫 PromQL 再回頭補定義,等於是先決定了要用哪把尺,才去問要量什麼——尺的刻度會反過來限制你能看見的東西,http_status < 500 就是最典型的例子,這把尺天生看不見 parser 壞掉、看不見 retrieval 取到不相關文件、看不見串流中途斷線。反過來,先寫 classifier、用測試把判定鎖住,PromQL 只是把已經想清楚的定義投影到時間序列上,尺是為了問題而磨的,不是問題被尺框住。

這樣做不會讓 AI 回答突然變正確,也不會讓 retrieval 突然只取回相關文件。它能做到的事情更基本,也更容易被低估:讓「系統正在說謊」跟「系統正在誠實地報告故障」這兩種狀態,在 dashboard 上看起來不一樣。第②到第⑦節談的所有機制——health check、user journey、classifier、PromQL、多視窗告警、依賴降級——最終都是為了守住這一件事。一個系統已經把使用者留在空白畫面,監控卻還告訴你一切正常,這是比故障本身更糟的狀況,因為它連「該不該緊張」這個最基本的判斷,都從值班的人手上拿走了。

Day 16 預告

Day 15 把 availability 的分子、分母從 HTTP status code 的預設假設中拉回產品判斷。Day 16 要處理另一個更常被誤讀的訊號:延遲。平均 latency 會把「大多數請求很快、少數請求極慢」的真相壓成一個看起來健康的數字,下一篇會實際刻意讓 5% 的請求變慢,觀察 P50、P95、P99 各自說了什麼。

延伸閱讀


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 15(上)|Availability:不是把 200 數一數
下一篇
Day 16(上)|平均 Latency 為什麼會騙人?請看尾端
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言